AndroidアプリをMVPで実装してみた
はじめに
今回、業務でMVPアーキテクチャパターンを採用しました。
一部クリーンアーキテクチャも利用していますが、基本的にはMVPで実装を進めました。
設計に答えはないのですが、この記事を機会にViewにロジックをモリモリ書いている方が、設計に興味を持って頂ければ幸いです。
※依存性はDaggerを利用して解決しています。ソースは省略しているので、適宜読み取ってください。
実装
Model
モデル層では、各種データクラスとのやり取りを行います。
ex) APIからデータ取得・ローカルDBからデータ取得
また、Viewの処理に依存させずに、「データを操作すること」に特化したクラスにすることで
違う画面でも同じようにデータ取得処理を行うことができます。
モデル層の中で更に役割を分割することもできます。今回はRepository+DataSource方式を採用しました。
RepositoryはDataSourceを使ってPresenterにデータを返却します。
DataSourceはその名の通り、どこからデータを取得するかを持ちます。
今回はRetrofitを採用しているため、DataSourceは極めてシンプルです。
public interface AddressDataSource { Single addresses(String token); } public interface AddressClient { @GET("addresses") Single addresses(@Header("Authorization") String token); } public class AddressRemoteDataSource implements AddressDataSource { private Retrofit mRetrofit; public AddressRemoteDataSource(Retrofit retrofit) { mRetrofit = retrofit; } @Override public Single addresses(String token) { return mRetrofit.create(AddressClient.class).addresses(token); } }
また、こちらではAddressDataSourceをinterfaceにすることで、flavorによるモック切り替えを行っています。
依存性については、Dagger2を利用して解決しています。※最下部参照
View
View層は基本的にはActivity/Fragmentを指します。Serviceなどでユーザーのイベントを受け取って処理する場合もView扱いできると思います。
Viewから直接Model層を利用してデータを扱うことは原則禁止です。Presenterに任せましょう。
また、ViewとPresenterはinterfaceを経由してやり取りするようにします。
Presenterが保持しているViewにDaggerを利用してinjectします。
こうすることで、Presenterのテスト時に、ViewをMockにすることができます。
public interface AddressListContract { interface View extends BaseView { void showProgress(); void hideProgress(); void showAddresses(List addresses); void showEmpty(); } interface Presenter extends BasePresenter { void getAddressList(); } } public class AddressListFragment extends BaseFragment implements AddressListContract.View { @Inject AddressListContract.Presenter mPresenter; @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mPresenter.getAddressList(); } @Override public void showProgress() { // fixme: プログレスの表示 } @Override public void hideProgress() { // fixme: プログレスの非表示 } @Override public void showAddresses(List addresses) { // fixme: データを表示 } @Override public void showEmpty() { // fixme: データがない場合の表示 } }
Presenter
Presenter層はRepositoryからデータを取得したり、Viewでプログレスを表示させたりと
いわゆるビジネスロジックを担当します。
クリーンアーキテクチャではUseCaseを使って、ロジックをPresenterから切り離すこともあります。
しかし、今回は複雑なビジネスロジックが存在しなかったため、Presenterに直接Repositoryを操作させています。
(ex プログレス出して、データ取って、画面に出して、プログレス消す)
public class AddressListPresenter implements AddressListContract.Presenter { private AddressListContract.View mView; private AddressRepository mAddressRepository; public AddressListPresenter(AddressListContract.View view, AddressRepository addressRepository) { mView = view; mAddressRepository = addressRepository; } @Override public void getAddressList() { mView.showProgress(); mAddressRepository.addresses("token_dummy") .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doFinally(mView::hideProgress) .subscribe(addressesResponse -> { if (addressesResponse.addresses.isEmpty()) { mView.showEmpty(); } else { mView.showAddresses(addressesResponse.addresses); } }, throwable -> { // fixme: 取得失敗時の処理 }); } }
Model層からの返却値をRxJava2を利用した形にしているのでPresenter層での操作がスッキリしています。
あまり特殊なロジックはないですが「データが無ければエンプティ表示をする」
という部分はPresenterが責任を持っています。
そしてエンプティ表示で何が表示されるのかはViewのみが責任を持ちます。
このようにそれぞれの層で責任を分割することで、テストが書きやすくなります。
PresenterではViewのshowEmptyが呼べていればOKです。
(ex Mockito.verify(mView).showEmpty()
)
Viewではダイアログでemptyを表示していればOK or Toastで表示など。
テスト
今回はPresenterのテストを書いています。(Mockitoを利用)
public class AddressListPresenterTest { @Mock AddressListContract.View mView; @Mock AddressRepository mAddressRepository; private AddressListPresenter mAddressListPresenter; @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); RxJavaPlugins.setIoSchedulerHandler(scheduler -> Schedulers.trampoline()); RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerCallable -> Schedulers.trampoline()); mAddressListPresenter = new AddressListPresenter(mView, mAddressRepository); } @After public void tearDown() throws Exception { RxJavaPlugins.reset(); RxAndroidPlugins.reset(); } @Test public void 一覧取得_正常系() throws Exception { AddressesResponse addressesResponse = new AddressesResponse(); addressesResponse.addresses = new ArrayList<>(); addressesResponse.addresses.add(new AddressesResponse.Address()); when(mAddressRepository.addresses("token_dummy")).thenReturn(Single.just(addressesResponse)); mAddressListPresenter.getAddressList(); verify(mView).showProgress(); verify(mView).hideProgress(); verify(mView, never()).showEmpty(); verify(mView).showAddresses(addressesResponse.addresses); } @Test public void 一覧取得_エンプティ表示() throws Exception { AddressesResponse addressesResponse = new AddressesResponse(); addressesResponse.addresses = new ArrayList<>(); when(mAddressRepository.addresses("token_dummy")).thenReturn(Single.just(addressesResponse)); mAddressListPresenter.getAddressList(); verify(mView).showProgress(); verify(mView).hideProgress(); verify(mView).showEmpty(); verify(mView, never()).showAddresses(any()); } }
Presenter以外の要素はモックにします。(interfaceにしているため可能)
こうすることで、Presenterの責任にのみフォーカスしてテストすることが可能です。
showEmpty()
されたら何が表示されるか、はPresenterのテストでは必要ありません。
まとめ
一部省略していますが、MVPのイメージができたでしょうか。
責任を明確にし、クラスを分割することでそれぞれのテストが簡潔に記載できます。
また、変更が発生した場合も最小限の影響で済むようになります。
例えばデータのやり取りとViewの処理が混ざっていると、データのやり取り部分を修正するだけでViewにも影響が出ます。
Viewに全ての処理を詰め込んでいる方は、まずMVPで処理を分けてみることをオススメします。
※Daggerは必須ではありませんが、テストを書くなら利用したほうが良いと思います。
コードはgistで良かったかなぁ・・・
その他
上部に入り切らないソースを以下に記載します。
アプリケーション共通で利用するモジュール
@Module public class AppModule { private Context mContext; public AppModule(Context context) { mContext = context; } @Singleton @Provides Context provideContext() { return mContext; } @Singleton @Provides Retrofit provideRetrofit(OkHttpClient client) { return new Retrofit.Builder() .client(client) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(GsonConverterFactory.create()) .baseUrl(BuildConfig.API_END_POINT) .build(); } @Singleton @Provides OkHttpClient provideOkHttpClient() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); if (BuildConfig.DEBUG) { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); builder.addInterceptor(logging); } return builder.build(); } }
アプリケーション共通のコンポーネント
@Singleton @Component(modules = {AppModule.class, RepositoryModule.class}) public interface AppComponent { Context getContext(); AddressRepository getAddressRepository(); }
データソースの取得先を保持しているモジュール
このモジュールをflavorで切り替えることで、データ取得先をモック・API・ローカルDBなど切り替えることが可能となります。
@Module public class RepositoryModule { @Singleton @Provides AddressDataSource provideAddressDataSource(Context context) { return new AddressMockDataSource(context); } }
Presenterのモジュール
@Module public class AddressListPresenterModule { private AddressListContract.View mView; public AddressListPresenterModule(AddressListContract.View view) { mView = view; } @Provides AddressListContract.Presenter providePresenter(AddressRepository addressRepository) { return new AddressListPresenter(mView, addressRepository); } }